Getting started topic

Getting started

The examples use a body-weight diary with skipped days, two readings on one morning and a fortnight with no readings. Nothing in the package is specific to weight.

Install

dependencies:
  state_space: ^0.1.0

No runtime dependencies, no platform channels, no native code.

A first fit

import 'package:state_space/state_space.dart';

final data = [
  Observation(0, 81.3),
  Observation(2, 81.0),
  Observation(3, 80.5),
  Observation(7, 80.0),
  Observation(8, 80.0),
  Observation(8, 79.8),
  Observation(10, 79.7),
  Observation(11, 79.7),
  Observation(12, 79.5),
  Observation(17, 79.3),
  Observation(18, 79.3),
  Observation(19, 79.2),
  Observation(20, 79.2),
  Observation(21, 79.4),
  Observation(22, 79.2),
  Observation(24, 79.3),
  Observation(27, 79.1),
];

final fitted = fit(StructuralModel.localLinearTrend(processVariance: 1), data);
print(fitted.warnings);         // []
final trend = fitted.model.smooth(data);

trend.mean[0];                  // the smoothed curve at the first reading
trend.credibleInterval(0);      // (lo: ..., hi: ...) around it

fit estimates how flexible the curve should be and smooth computes the posterior. fit ignores the processVariance you pass: it scans a fixed bracket, thirteen decades wide, in the ratio of the process variance to the noise (about 1e-8.7 to 1e4.3). lowerLogRatio and upperLogRatio move it. The default suits time in days.

Check fitted.warnings before using a fit. Four readings, for instance, do not determine a smoothing level, and the fit says so; Choosing a model explains each warning.

What comes back

smooth returns a SmoothingResult: parallel read-only arrays, one entry per output time, in the order you asked for them.

times the output times
mean the posterior mean of the signal, all components together
variance its variance: uncertainty about the signal, not about the next reading
trendSlope, trendSlopeVariance the trend component's rate of change, or null if no component has one
componentMean(i), componentVariance(i) what component i contributed
componentSlope(i), componentSlopeVariance(i) its rate of change, if it has one
coefficients regression coefficients, with standard errors
logMarginalLikelihood see Choosing a model before comparing these

trendSlope is the trend's own rate. With a weekly seasonal in the model it is not the derivative of mean, which includes the weekly wiggle.

There are two kinds of interval:

trend.credibleInterval(i);    // where the underlying trend is
trend.predictiveInterval(i);  // where the next reading would fall
trend.credibleBand();         // the first at every output time, as lo and hi arrays

Draw the credible band around the curve. It is narrow, and most individual readings fall outside it, because it describes the trend rather than the next reading. About 95 % of the observations should sit inside the 95 % predictive band.

Dates

Time is a double. TimeAxis converts calendar dates to days since an origin and back, counting every calendar day as one, including the 23- and 25-hour days at a change of clocks:

final axis = TimeAxis.days(readings.first.date);
final data = [for (final r in readings) axis.observation(r.date, r.kg)];
// ... smooth, then map each output time back:
final dates = [for (final t in trend.times) axis.dateAt(t)];

Subtracting two DateTimes and dividing by 24 hours is off by an hour after a change of clocks, so readings taken at 07:00 every day no longer land on whole days.

Irregular data

final data = [
  Observation(0, 81.2),
  Observation(1, 80.9),
  Observation(9, 80.4),   // an eight-day gap
  Observation(9, 80.6),   // and two readings at the same instant
];

There is no interpolation or resampling, and duplicates do not need averaging. A gap is a larger dt in the recursion, a repeated timestamp is dt = 0, and a day with no reading is a step without an update.

Observations must be sorted by time. Results come back in the order you supplied.

Asking for output between the readings

Pass a grid. Grid points are steps with no observation attached, so each one costs one more step of the recursion.

final everyDay = [for (var d = 0; d <= 30; d++) d.toDouble()];
final daily = fitted.model.smooth(data, grid: everyDay);

A grid may extend past the data at either end; the band widens there. Where a grid time coincides exactly with an observation, the reported state includes that observation.

Slopes and forecasts

The trend carries its own rate of change:

daily.trendSlope![10];          // signal units per time unit
daily.trendSlopeVariance![10];  // and how sure it is

A forecast is the same recursion with no more observations:

final horizon = [for (var d = 31; d <= 60; d++) d.toDouble()];
final ahead = fitted.model.forecast(data, horizon);

ahead.credibleInterval(0);    // where the signal is going
ahead.predictiveInterval(0);  // where an actual reading would fall

The band widens quickly. For a local linear trend the forecast variance grows like the cube of the horizon, because the slope itself is uncertain and keeps drifting. This is expected behaviour.

If the first horizon time equals the last observation time, the forecast there is the filtered state: conditioned on everything up to and including that reading. Use it for a value that should not change once it has been shown.

Readings you trust differently

relativeVariance scales one observation's noise against the model's noise level, so an average of two weighings and a single quick one can be in the same series:

Observation(4, 80.4);                          // an ordinary reading
Observation(5, 80.5, relativeVariance: 0.5);   // trusted twice as much
Observation(6, 79.1, relativeVariance: 4.0);   // trusted half as much

Bad readings

The fit is Gaussian, so one mistyped value (801 for 80.1, or 8.01) inflates the noise estimate and moves the curve for months around it. fit measures how far each reading is from what the rest of the data predicts, and names the worst one in warnings when it is more than six typical errors away:

var fitted = fit(model, data);
final worst = fitted.largestResidual;
if (worst != null && worst.score.abs() > FitResult.outlierScore) {
  final screened = [
    for (final o in data)
      o.time == worst.time
          ? Observation(o.time, o.value, relativeVariance: 1e6)
          : o
  ];
  fitted = fit(model, screened);
}

A very large relativeVariance effectively ignores the reading but keeps it in the list, so the output times do not change. Removing it works too.

Noise floors

By default the noise level is whatever explains the data best. On a run of nearly identical readings that can be smaller than any real scale could achieve, with a band that is far too narrow.

fit(model, data, minimumMeasurementVariance: 0.029 * 0.029);  // rounds to 100 g
fit(model, data, fixedMeasurementVariance: 0.2 * 0.2);        // known noise

minimumMeasurementVariance is a floor. The fit runs normally and is only redone with the noise pinned if the free estimate is below the floor, so it costs nothing otherwise. A display that rounds to 100 g adds a rounding error with standard deviation 0.1 / sqrt(12), about 0.029 kg. Use a floor whenever a Matern is in the model.

fixedMeasurementVariance pins the noise level for when it is known, and estimates the smoothing given it.

Choosing the smoothing yourself

If the stiffness of the curve is a user setting rather than an estimate, skip fit. Build the model at the ratio you want and let the data set the scale:

// A stiffness from a user setting: process variance = ratio * noise variance.
final chosen = StructuralModel.localLinearTrend(processVariance: ratio)
    .withEstimatedScale(data);
final trend = chosen.smooth(data, grid: everyDay);

withEstimatedScale keeps every variance ratio and sets the noise level to its restricted maximum likelihood estimate, in one forward pass. The curve depends only on the ratio; the noise level only changes the band. Pass minimumMeasurementVariance to put a floor under the estimate.

When there is not enough data

A trend has two flat directions, a level and a slope, so it needs readings at two distinct times. Each harmonic of a seasonal adds two more, and each regression column one. fit needs one reading more than that to estimate a noise level from. With fewer, the package throws an UnderdeterminedModelException whose message says what is missing:

try {
  final fitted = fit(model, data);
} on UnderdeterminedModelException catch (e) {
  // Too little data, or two components the data cannot tell apart.
}

All the package's data-dependent failures are StateSpaceExceptions: UnderdeterminedModelException, and NumericalBreakdownException for a component whose process noise is not a covariance. Invalid arguments throw ArgumentError.

ApproximateDiffuse returns a very wide band where exact initialisation throws. It is sound only with a time unit that keeps rates of change near order one, such as days.

A band computed from a handful of readings treats the estimated noise level as known, so in the first week or two of a diary it is too narrow. A noise floor at a realistic spread, rather than at the instrument's resolution, helps.

Refitting as data arrives

A diary that gains one reading a day rarely moves its optimum far, so there is no need to repeat the full scan:

var fitted = fit(model, data);                          // cold, once
fitted = fit(fitted.model, longerData,                  // warm, after
    start: SearchStart.previousParameters);

On two years of daily readings with a trend, a weekly seasonal and a Matérn, adding one observation took 573 filter passes cold and 232 warm, with the same likelihood to 1e-4. A warm start cannot jump to a different basin, so run a cold fit when the data changes character rather than just growing.

Isolates

A model and its posterior are plain data, so they can be sent to another isolate:

final posterior = await Isolate.run(() => model.smooth(data));

In Flutter, compute does the same. test/isolate_test.dart sends a model across, uses it there, and brings back a posterior, a forecast and a set of diagnostics.

Where to go next

  • Choosing a model: which components, and how to compare them
  • Components: what each one is, and which kernels are reachable
  • How it works: the Gaussian process, the SDE and the filter
  • Validation: what is checked, against what, and how closely

Classes

ForecastResult Getting started
The signal projected past the end of the data.
Observation Getting started
A single scalar measurement at a point in time.
SmoothingResult Getting started
The posterior of a model, evaluated at each requested output time.
StructuralModel Getting started Choosing a model
An additive structural time-series model: a list of components plus observation noise.
TimeAxis Getting started
Converts between calendar dates and times in days since an origin, which is the time unit the components' defaults are chosen for.

Exceptions / Errors

StateSpaceException Getting started
A model that cannot produce an answer on the data it was given.